Skip to content

💥 Standalone Activities support#2029

Merged
maciejdudko merged 11 commits intotemporalio:mainfrom
maciejdudko:standalone-activities
Apr 30, 2026
Merged

💥 Standalone Activities support#2029
maciejdudko merged 11 commits intotemporalio:mainfrom
maciejdudko:standalone-activities

Conversation

@maciejdudko
Copy link
Copy Markdown
Contributor

@maciejdudko maciejdudko commented Apr 28, 2026

💥 Breaking change

This PR alters Activity Info interface in backward-incompatible way by making workflowExecution, workflowNamespace and workflowType properties optional. As long as Standalone Activities are not being used, it is safe to use ! to remove type errors, however the recommended migration path is to check the inWorkflow property and handle both cases - this way the activity can be run in standalone context.

What was changed

Implemented support for Standalone Activities.

Telemetry support will be done separately, see #2031

Why?

Feature request: temporalio/features#706

Checklist

  1. Closes [Feature Request] Support standalone activities #1851

  2. How was this tested:

  • New test file test-standalone-activities.ts
  • New tests added to test-async-completion.ts

@maciejdudko maciejdudko requested a review from a team as a code owner April 28, 2026 16:30
throw failedErr;
}
}
}
Copy link
Copy Markdown
Contributor

@dandavison dandavison Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this long-polling loop will fail to wait for longer than the server's long-poll timeout due to not handling DEADLINE_EXCEEDED. Can you compare to the implementations in Go and Python and add an integration test that demonstrates it can wait for longer than the server timeout, and for longer than the per-attempt client-side deadline? And also that it responds correctly when the activity is cancelled on the server.

Python:

        while True:
            try:
                res = await self._client.workflow_service.poll_activity_execution(
                    req,
                    retry=True,
                    metadata=rpc_metadata,
                    timeout=rpc_timeout,
                )
                if res.HasField("outcome"):
                    self._known_outcome = res.outcome
                    return
            except RPCError as err:
                if err.status == RPCStatusCode.DEADLINE_EXCEEDED:
                    # Deadline exceeded is expected with long polling; retry
                    continue
                elif err.status == RPCStatusCode.CANCELLED:
                    raise asyncio.CancelledError() from err
                else:
                    raise
            except asyncio.CancelledError:
                raise

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you also check that activity cancellation is handled correctly in this loop?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The server behavior for polling timeout is to return empty response. This is handled correctly. The code throws exception on client-side deadline, but it could be argued it's the expected behavior - we can revisit is some other time, though it does match behavior of workflow result polling. Client-side cancellation is handled correctly.

Added tests to verify long long polling and client-side cancellation.

Comment thread packages/client/src/activity-client.ts Outdated
Copy link
Copy Markdown
Member

@Sushisource Sushisource left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall making sense but we need to find a way to provide better type safety

Comment thread packages/client/src/activity-client.ts Outdated
Comment thread packages/client/src/activity-client.ts Outdated
Comment thread packages/client/src/activity-client.ts Outdated
Comment thread packages/client/src/activity-client.ts Outdated
Comment thread packages/client/src/activity-client.ts
Comment thread packages/client/src/activity-client.ts
Comment thread packages/client/src/types.ts Outdated
Comment thread packages/client/src/activity-client.ts Outdated
Comment thread packages/client/src/activity-client.ts Outdated
Comment thread packages/worker/src/worker.ts Outdated
@maciejdudko maciejdudko force-pushed the standalone-activities branch from 9cd8d9e to 8a7d1f2 Compare April 29, 2026 17:46
Copy link
Copy Markdown
Member

@Sushisource Sushisource left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LMK if this is ready for final review or not and I can make a last pass

Comment thread packages/client/src/activity-client.ts
Comment thread packages/client/src/activity-client.ts Outdated
@maciejdudko
Copy link
Copy Markdown
Contributor Author

@Sushisource PR is ready for final review.

Copy link
Copy Markdown
Member

@Sushisource Sushisource left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good on my end but @mjameswh or @chris-olszewski should approve too

idConflictPolicy: encodeActivityIdConflictPolicy(input.options.idConflictPolicy),
searchAttributes: { indexedFields: searchAttributes },
header: input.headers,
userMetadata: await encodeUserMetadata(this.dataConverter, input.options.summary, undefined),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I guess you can only set them from inside the activity? We should support it in both places, but can be a follow up PR. Let's make sure we track it.

Copy link
Copy Markdown
Member

@chris-olszewski chris-olszewski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lgtm just minor suggestions that are not blocking

*
* @experimental Standalone Activities are experimental. APIs may be subject to change.
*/
readonly inWorkflow: boolean;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking just since this is currently experimental, but making Info enforce that inWorkflow: true implies that workflowExecution etc are present would be nice

e.g.

  type Info = BaseInfo & (
    | { readonly inWorkflow: true;
        readonly workflowExecution: { workflowId: string; runId: string };
        readonly workflowNamespace: string;
        readonly workflowType: string;
        readonly activityRunId?: undefined }
    | { readonly inWorkflow: false;
        readonly activityRunId: string;
        readonly workflowExecution?: undefined;
        readonly workflowNamespace?: undefined;
        readonly workflowType?: undefined }
  );

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't really work in a way that's helpful to the users, because ActivityContext.current().info still has to be the most general type and TS compiler doesn't support type assertions on this.

type ErrorDetailsName = `temporal.api.errordetails.v1.${keyof typeof temporal.api.errordetails.v1}`;
type FailureName = `temporal.api.failure.v1.${keyof typeof temporal.api.failure.v1}`;
export type ErrorDetailsName = `temporal.api.errordetails.v1.${keyof typeof temporal.api.errordetails.v1}`;
export type FailureName = `temporal.api.failure.v1.${keyof typeof temporal.api.failure.v1}`;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe this needs to be made public

Suggested change
export type FailureName = `temporal.api.failure.v1.${keyof typeof temporal.api.failure.v1}`;
type FailureName = `temporal.api.failure.v1.${keyof typeof temporal.api.failure.v1}`;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not public, just privately exported within package. It's so it can be used in activity-client.ts.

for (const entry of getGrpcStatusDetails(err) ?? []) {
if (!entry.type_url || !entry.value) continue;
if (
(trimGrpcTypeUrl(entry.type_url) as ErrorDetailsName) ===
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to be more honest with the cast

Suggested change
(trimGrpcTypeUrl(entry.type_url) as ErrorDetailsName) ===
(trimGrpcTypeUrl(entry.type_url) as ErrorDetailsName | '') ===

function getSchedulingWorkflowHandle(): WorkflowHandle {
const { info, client } = Context.current();
if (!info.inWorkflow) {
throw new Error('Not in workflow');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not major, but we usually throw IllegalStateError when attempting to use workflow only code in non-workflow contexts

@maciejdudko maciejdudko changed the title Standalone Activities support 💥 Standalone Activities support Apr 30, 2026
@maciejdudko maciejdudko merged commit 0e3b127 into temporalio:main Apr 30, 2026
69 of 77 checks passed
@maciejdudko maciejdudko deleted the standalone-activities branch April 30, 2026 19:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Support standalone activities

4 participants